/*
 * Tencent is pleased to support the open source community by making
 * Hippy available.
 *
 * Copyright (C) 2017-2019 THL A29 Limited, a Tencent company.
 * All rights reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */


import { camelize } from 'shared/util';
import { tryConvertNumber, warn } from '@vue/util/index';
import translateColor from './color-parser';

const PROPERTIES_MAP: any = {
  textDecoration: 'textDecorationLine',
  boxShadowOffset: 'shadowOffset',
  boxShadowOffsetX: 'shadowOffsetX',
  boxShadowOffsetY: 'shadowOffsetY',
  boxShadowOpacity: 'shadowOpacity',
  boxShadowRadius: 'shadowRadius',
  boxShadowSpread: 'shadowSpread',
  boxShadowColor: 'shadowColor',
  caretColor: 'caret-color',
};

// linear-gradient direction description map
const LINEAR_GRADIENT_DIRECTION_MAP: any = {
  totop: '0',
  totopright: 'totopright',
  toright: '90',
  tobottomright: 'tobottomright',
  tobottom: '180', // default value
  tobottomleft: 'tobottomleft',
  toleft: '270',
  totopleft: 'totopleft',
};

const DEGREE_UNIT = {
  TURN: 'turn',
  RAD: 'rad',
  DEG: 'deg',
};

const commentRegexp = /\/\*[^*]*\*+([^/*][^*]*\*+)*\//g;

/**
 * Trim `str`.
 */
function trim(str: any) {
  return str ? str.trim() : '';
}


/**
 * Adds non-enumerable parent node reference to each node.
 */
function addParent(obj: any, parent: any) {
  const isNode = obj && typeof obj.type === 'string';
  const childParent = isNode ? obj : parent;
  Object.keys(obj).forEach((k) => {
    const value = obj[k];
    if (Array.isArray(value)) {
      value.forEach((v) => {
        addParent(v, childParent);
      });
    } else if (value && typeof value === 'object') {
      addParent(value, childParent);
    }
  });
  if (isNode) {
    Object.defineProperty(obj, 'parent', {
      configurable: true,
      writable: true,
      enumerable: false,
      value: parent || null,
    });
  }
  return obj;
}

/**
 * Convert the px unit to pt directly.
 * We found to the behavior of convert the unit directly is correct.
 */
function convertPxUnitToPt(value: any) {
  // If value is number just ignore
  if (Number.isInteger(value)) {
    return value;
  }
  // If value unit is rpx, don't need to filter
  if (typeof value === 'string' && value.endsWith('rpx')) {
    return value;
  }
  // If value unit is px, change to use pt as 1:1.
  if (typeof value === 'string' && value.endsWith('px')) {
    const num = parseFloat(value.slice(0, value.indexOf('px')));
    if (!Number.isNaN(num)) {
      value = num;
    }
  }
  return value;
}

/**
 * Parse the CSS to be AST tree.
 */
function parseCSS(css: any, options: any) {
  options = options || {};

  /**
   * Positional.
   */
  let lineno = 1;
  let column = 1;

  /**
   * Update lineno and column based on `str`.
   */
  function updatePosition(str: any) {
    const lines = str.match(/\n/g);
    if (lines) lineno += lines.length;
    const i = str.lastIndexOf('\n');
    column = ~i ? str.length - i : column + str.length;
  }

  /**
   * Mark position and patch `node.position`.
   */
  function position() {
    const start = { line: lineno, column };
    return (node: any) => {
      node.position = new Position(start);
      whitespace();
      return node;
    };
  }

  /**
   * Store position information for a node
   */
  class Position {
    content: any;
    end: any;
    source: any;
    start: any;
    constructor(start: any) {
      this.start = start;
      this.end = { line: lineno, column };
      this.source = options.source;
      this.content = css;
    }
  }

  /**
   * Error `msg`.
   */
  const errorsList: any = [];
  function error(msg: any) {
    const err = new Error(`${options.source}:${lineno}:${column}: ${msg}`);
    (err as any).reason = msg;
    (err as any).filename = options.source;
    (err as any).line = lineno;
    (err as any).column = column;
    (err as any).source = css;
    if (options.silent) {
      errorsList.push(err);
    } else {
      throw err;
    }
  }

  /**
   * Parse stylesheet.
   */
  function stylesheet() {
    const rulesList = rules();

    return {
      type: 'stylesheet',
      stylesheet: {
        source: options.source,
        rules: rulesList,
        parsingErrors: errorsList,
      },
    };
  }

  /**
   * Opening brace.
   */
  function open() {
    return match(/^{\s*/);
  }

  /**
   * Closing brace.
   */
  function close() {
    return match(/^}/);
  }

  /**
   * Parse ruleset.
   */
  function rules() {
    let node;
    const rules: any = [];
    whitespace();
    comments(rules);
    // eslint-disable-next-line no-cond-assign
    while (css.length && css.charAt(0) !== '}' && (node = atrule() || rule())) {
      if (node !== false) {
        rules.push(node);
        comments(rules);
      }
    }
    return rules;
  }

  /**
   * Match `re` and return captures.
   */
  function match(re: any) {
    const m = re.exec(css);
    if (!m) {
      return null;
    }
    const str = m[0];
    updatePosition(str);
    css = css.slice(str.length);
    return m;
  }

  /**
   * Parse whitespace.
   */
  function whitespace() {
    match(/^\s*/);
  }

  /**
   * Parse comments;
   */
  function comments(rules: any[] = []): any[] {
    let c;
    rules = rules || [];
    while ((c = comment()) !== null) {
      if (c !== false) {
        rules.push(c);
      }
    }
    return rules;
  }

  /**
   * Parse comment.
   */
  function comment() {
    const pos = position();
    if (css.charAt(0) !== '/' || css.charAt(1) !== '*') {
      return null;
    }
    let i = 2;
    while (css.charAt(i) !== '' && (css.charAt(i) !== '*' || css.charAt(i + 1) !== '/')) {
      i += 1;
    }
    i += 2;
    if (css.charAt(i - 1) === '') {
      return error('End of comment missing');
    }
    const str = css.slice(2, i - 2);
    column += 2;
    updatePosition(str);
    css = css.slice(i);
    column += 2;
    return pos({
      type: 'comment',
      comment: str,
    });
  }

  /**
   * Parse selector.
   */

  function selector() {
    const m = match(/^([^{]+)/);
    if (!m) {
      return null;
    }
    /* @fix Remove all comments from selectors
     * http://ostermiller.org/findcomment.html */
    return trim(m[0])
      .replace(/\/\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*\/+/g, '')
      .replace(/"(?:\\"|[^"])*"|'(?:\\'|[^'])*'/g, (m: any) => m.replace(/,/g, '\u200C'))
      .split(/\s*(?![^(]*\)),\s*/)
      .map((s: any) => s.replace(/\u200C/g, ','));
  }

  /**
   * convert string value to string degree
   * @param {string} value
   * @param {string} unit
   */
  function convertToDegree(value: any, unit = DEGREE_UNIT.DEG) {
    const convertedNumValue = parseFloat(value);
    let result = value || '';
    const [, decimals] = value.split('.');
    if (decimals && decimals.length > 2) {
      result = convertedNumValue.toFixed(2);
    }
    switch (unit) {
      // turn unit
      case DEGREE_UNIT.TURN:
        result = `${(convertedNumValue * 360).toFixed(2)}`;
        break;
      // radius unit
      case DEGREE_UNIT.RAD:
        result = `${(180 / Math.PI * convertedNumValue).toFixed(2)}`;
        break;
      default:
    }
    return result;
  }

  /**
   * parse gradient angle or direction
   * @param {string} value
   */
  function getLinearGradientAngle(value: any) {
    const processedValue = (value || '').replace(/\s*/g, '').toLowerCase();
    const reg = /^([+-]?(?=(?<digit>\d+))\k<digit>\.?\d*)+(deg|turn|rad)|(to\w+)$/g;
    const valueList = reg.exec(processedValue);
    if (!Array.isArray(valueList)) return;
    // default direction is to bottom, i.e. 180degree
    let angle = '180';
    const [direction, angleValue, angleUnit] = valueList;
    if (angleValue && angleUnit) { // angle value
      angle = convertToDegree(angleValue, angleUnit);
    } else if (direction && typeof LINEAR_GRADIENT_DIRECTION_MAP[direction] !== 'undefined') { // direction description
      angle = LINEAR_GRADIENT_DIRECTION_MAP[direction];
    } else {
      warn('linear-gradient direction or angle is invalid, default value [to bottom] would be used');
    }
    return angle;
  }

  /**
   * parse gradient color stop
   * @param {string} value
   */
  function getLinearGradientColorStop(value: any) {
    const processedValue = (value || '').replace(/\s+/g, ' ').trim();
    const [color, percentage] = processedValue.split(/\s+(?![^(]*?\))/);
    const percentageCheckReg = /^([+-]?\d+\.?\d*)%$/g;
    if (color && !percentageCheckReg.exec(color) && !percentage) {
      return {
        color: translateColor(color),
      };
    }
    if (color && percentageCheckReg.exec(percentage)) {
      return {
        // color stop ratio
        ratio: parseFloat(percentage.split('%')[0]) / 100,
        color: translateColor(color),
      };
    }
    warn('linear-gradient color stop is invalid');
  }

  /**
   * parse backgroundImage
   * @param {string} property
   * @param {string|Object|number|boolean} value
   * @returns {(string|{})[]}
   */
  function parseBackgroundImage(property: any, value: any) {
    let processedValue = value;
    let processedProperty = property;
    if (value.indexOf('linear-gradient') === 0) {
      processedProperty = 'linearGradient';
      const valueString = value.substring(value.indexOf('(') + 1, value.lastIndexOf(')'));
      const tokens = valueString.split(/,(?![^(]*?\))/);
      const colorStopList: any = [];
      processedValue = {};
      tokens.forEach((value: any, index: any) => {
        if (index === 0) {
          // the angle of linear-gradient parameter can be optional
          const angle = getLinearGradientAngle(value);
          if (angle) {
            processedValue.angle = angle;
          } else {
            // if angle ignored, default direction is to bottom, i.e. 180degree
            processedValue.angle = '180';
            const colorObject = getLinearGradientColorStop(value);
            if (colorObject) colorStopList.push(colorObject);
          }
        } else {
          const colorObject = getLinearGradientColorStop(value);
          if (colorObject) colorStopList.push(colorObject);
        }
      });
      processedValue.colorStopList = colorStopList;
    } else {
      const regexp = /(?:\(['"]?)(.*?)(?:['"]?\))/;
      const executed = regexp.exec(value);
      if (executed && executed.length > 1) {
        [, processedValue] = executed;
      }
    }
    return [processedProperty, processedValue];
  }

  /**
   * Parse declaration.
   */
  function declaration() {
    const pos = position();
    // prop
    let prop = match(/^(\*?[-#/*\\\w]+(\[[0-9a-z_-]+\])?)\s*/);
    if (!prop) {
      return null;
    }
    prop = trim(prop[0]);
    // :
    if (!match(/^:\s*/)) {
      return error('property missing \':\'');
    }
    // val
    const propertyName = prop.replace(commentRegexp, '');
    const camelizedProperty = camelize(propertyName);
    let property = (() => {
      const property = PROPERTIES_MAP[camelizedProperty];
      if (property) {
        return property;
      }
      return camelizedProperty;
    })();
    const val = match(/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^)]*?\)|[^};])+)/);
    let value = val ? trim(val[0]).replace(commentRegexp, '') : '';
    switch (property) {
      case 'backgroundImage': {
        [property, value] = parseBackgroundImage(property, value);
        break;
      }
      case 'transform': {
        const regex = /(\w+\s*)(?:\(['"]?)(.*?)(?:['"]?\))/g;
        const oldValue = value;
        value = [];
        let group;
        while (group = regex.exec(oldValue)) {
          const key = group[1];
          let v = group[2];
          if (v.indexOf('.') === 0) {
            v = `0${v}`;
          }

          if (parseFloat(v).toString() === v) {
            v = parseFloat(v);
          }

          const transform: any = {};
          transform[key] = v;
          value.push(transform);
        };
        break;
      }
      case 'fontWeight':
        // Keep string and going on.
        break;
      case 'textShadowOffset': {
        const pos = value.split(' ')
          .filter((v: any) => v)
          .map((v: any) => convertPxUnitToPt(v));
        const [width] = pos;
        let [, height] = pos;
        if (!height) {
          height = width;
        }
        value = {
          width,
          height,
        };
        break;
      }
      case 'shadowOffset': {
        const pos = value.split(' ')
          .filter((v: any) => v)
          .map((v: any) => convertPxUnitToPt(v));
        const [x] = pos;
        let [, y] = pos;
        if (!y) {
          y = x;
        }
        // FIXME: should not be width and height, should be x and y.
        value = {
          x,
          y,
        };
        break;
      }
      case 'collapsable':
        value = value !== 'false';
        break;
      default: {
        value = tryConvertNumber(value);
        // Convert the px to pt for specific properties
        value = convertPxUnitToPt(value);
      }
    }

    const ret = pos({
      type: 'declaration',
      value,
      property,
    });
    // ;
    match(/^[;\s]*/);
    return ret;
  }

  /**
   * Parse declarations.
   */
  function declarations() {
    let decls: any = [];
    if (!open()) return error('missing \'{\'');
    comments(decls);
    // declarations
    let decl;
    while ((decl = declaration()) !== null) {
      if (decl !== false) {
        if (Array.isArray(decl)) {
          decls = decls.concat(decl);
        } else {
          decls.push(decl);
        }
        comments(decls);
      }
    }
    if (!close()) return error('missing \'}\'');
    return decls;
  }

  /**
   * Parse keyframe.
   */
  function keyframe() {
    let m;
    const vals: any[] = [];
    const pos = position();
    while ((m = match(/^((\d+\.\d+|\.\d+|\d+)%?|[a-z]+)\s*/)) !== null) {
      vals.push(m[1]);
      match(/^,\s*/);
    }
    if (!vals.length) {
      return null;
    }
    return pos({
      type: 'keyframe',
      values: vals,
      declarations: declarations(),
    });
  }

  /**
   * Parse keyframes.
   */
  function atkeyframes() {
    const pos = position();
    let m = match(/^@([-\w]+)?keyframes\s*/);
    if (!m) {
      return null;
    }
    const vendor = m[1];
    // identifier
    m = match(/^([-\w]+)\s*/);
    if (!m) {
      return error('@keyframes missing name');
    }
    const name = m[1];
    if (!open()) return error('@keyframes missing \'{\'');
    let frame;
    let frames = comments();
    while ((frame = keyframe()) !== null) {
      frames.push(frame);
      frames = frames.concat(comments());
    }
    if (!close()) return error('@keyframes missing \'}\'');
    return pos({
      type: 'keyframes',
      name,
      vendor,
      keyframes: frames,
    });
  }

  /**
   * Parse supports.
   */
  function atsupports() {
    const pos = position();
    const m = match(/^@supports *([^{]+)/);
    if (!m) {
      return null;
    }
    const supports = trim(m[1]);
    if (!open()) return error('@supports missing \'{\'');
    const style = comments().concat(rules());
    if (!close()) return error('@supports missing \'}\'');
    return pos({
      type: 'supports',
      supports,
      rules: style,
    });
  }

  /**
   * Parse host.
   */
  function athost() {
    const pos = position();
    const m = match(/^@host\s*/);
    if (!m) {
      return null;
    }
    if (!open()) {
      return error('@host missing \'{\'');
    }
    const style = comments().concat(rules());
    if (!close()) {
      return error('@host missing \'}\'');
    }
    return pos({
      type: 'host',
      rules: style,
    });
  }

  /**
   * Parse media.
   */
  function atmedia() {
    const pos = position();
    const m = match(/^@media *([^{]+)/);
    if (!m) {
      return null;
    }
    const media = trim(m[1]);
    if (!open()) {
      return error('@media missing \'{\'');
    }
    const style = comments().concat(rules());
    if (!close()) {
      return error('@media missing \'}\'');
    }
    return pos({
      type: 'media',
      media,
      rules: style,
    });
  }


  /**
   * Parse custom-media.
   */
  function atcustommedia() {
    const pos = position();
    const m = match(/^@custom-media\s+(--[^\s]+)\s*([^{;]+);/);
    if (!m) {
      return null;
    }

    return pos({
      type: 'custom-media',
      name: trim(m[1]),
      media: trim(m[2]),
    });
  }

  /**
   * Parse paged media.
   */
  function atpage() {
    const pos = position();
    const m = match(/^@page */);
    if (!m) {
      return null;
    }
    const sel = selector() || [];
    if (!open()) {
      return error('@page missing \'{\'');
    }
    let decls = comments();
    // declarations
    let decl;
    while ((decl = declaration()) !== null) {
      decls.push(decl);
      decls = decls.concat(comments());
    }
    if (!close()) {
      return error('@page missing \'}\'');
    }
    return pos({
      type: 'page',
      selectors: sel,
      declarations: decls,
    });
  }

  /**
   * Parse document.
   */
  function atdocument() {
    const pos = position();
    const m = match(/^@([-\w]+)?document *([^{]+)/);
    if (!m) {
      return null;
    }
    const vendor = trim(m[1]);
    const doc = trim(m[2]);
    if (!open()) {
      return error('@document missing \'{\'');
    }
    const style = comments().concat(rules());
    if (!close()) {
      return error('@document missing \'}\'');
    }
    return pos({
      type: 'document',
      document: doc,
      vendor,
      rules: style,
    });
  }

  /**
   * Parse font-face.
   */
  function atfontface() {
    const pos = position();
    const m = match(/^@font-face\s*/);
    if (!m) {
      return null;
    }
    if (!open()) {
      return error('@font-face missing \'{\'');
    }
    let decls = comments();
    // declarations
    let decl;
    while ((decl = declaration()) !== null) {
      decls.push(decl);
      decls = decls.concat(comments());
    }
    if (!close()) {
      return error('@font-face missing \'}\'');
    }
    return pos({
      type: 'font-face',
      declarations: decls,
    });
  }

  /**
   * Parse import
   */
  const atimport = compileAtRule('import');

  /**
   * Parse charset
   */
  const atcharset = compileAtRule('charset');

  /**
   * Parse namespace
   */
  const atnamespace = compileAtRule('namespace');

  /**
   * Parse non-block at-rules
   */
  function compileAtRule(name: any) {
    const re = new RegExp(`^@${name}\\s*([^;]+);`);
    return () => {
      const pos = position();
      const m = match(re);
      if (!m) {
        return null;
      }
      const ret: any = { type: name };
      ret[name] = m[1].trim();
      return pos(ret);
    };
  }

  /**
   * Parse at rule.
   */
  function atrule() {
    if (css[0] !== '@') {
      return null;
    }
    return atkeyframes()
      || atmedia()
      || atcustommedia()
      || atsupports()
      || atimport()
      || atcharset()
      || atnamespace()
      || atdocument()
      || atpage()
      || athost()
      || atfontface();
  }

  /**
   * Parse rule.
   */
  function rule() {
    const pos = position();
    const sel = selector();
    if (!sel) return error('selector missing');
    comments();
    return pos({
      type: 'rule',
      selectors: sel,
      declarations: declarations(),
    });
  }
  // @ts-expect-error TS(2554): Expected 2 arguments, but got 1.
  return addParent(stylesheet());
}

export default parseCSS;
export {
  PROPERTIES_MAP,
};
